package com.bullsora.kindlerss.business;

import com.bullsora.kindlerss.business.mail.KindleMailSender;
import com.bullsora.kindlerss.model.*;
import com.bullsora.kindlerss.repository.FeedRepository;
import com.bullsora.kindlerss.repository.GenericOperations;
import com.google.appengine.api.datastore.Text;
import com.google.appengine.api.memcache.Expiration;
import com.google.appengine.api.memcache.MemcacheService;
import com.google.appengine.api.taskqueue.Queue;
import com.google.appengine.api.taskqueue.QueueFactory;
import com.google.appengine.api.taskqueue.TaskHandle;
import com.google.appengine.api.taskqueue.TaskOptions;
import com.googlecode.objectify.Key;
import com.sun.syndication.feed.synd.SyndContent;
import com.sun.syndication.feed.synd.SyndEntry;
import com.sun.syndication.feed.synd.SyndFeed;
import com.sun.syndication.io.SyndFeedInput;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.quartz.CronExpression;
import org.springframework.stereotype.Component;
import org.w3c.dom.Document;

import javax.xml.parsers.DocumentBuilderFactory;
import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.TimeZone;

import static com.google.appengine.api.taskqueue.TaskOptions.Builder.withUrl;


/**
 * @author sorin.buliarca@iquesint.com
 */
@SuppressWarnings({"unchecked"})
@Component
public class SubscriptionsService {

  public static final Log LOG = LogFactory.getLog(SubscriptionsService.class);
  public static final int FEED_UPDATE_THRESHOLD = 15;
  public static final String FILE_SUFFIX_PATTERN = "EE HH:mm dd MMM";
  public static final String SEND_SUBSCRIPTION_PATTERN = "/subscriptions/%1$s/sendNewEntries";

  private StructuredHtmlGenerator htmlGenerator;

  private KindleMailSender mailSender;

  private GenericOperations genericOperations;

  private FeedRepository feedRepository;

  MemcacheService memcacheService;

  private boolean scheduleTasks = true;

  public void scheduleSubscriptionsSend() {
    List<Subscription> subscriptions = genericOperations.findAll(Subscription.class);
    for (Subscription subscription : subscriptions) {
      /*  schedule a new send only if the last check was missed in order to ensure all subscriptions
      are scheduled for sending. */
      Date nextCheck = subscription.getNextCheck();
      if (nextCheck == null || nextCheck.before(new Date())) {
        scheduleNextSend(subscription);
        genericOperations.persist(subscription);
      }
    }
  }

  private void updateFeed(String url) {
    FeedUrl feedUrl = feedRepository.getFeedUrlByUrl(url);
    if (feedUrl == null) {
      LOG.error("No feed url found with URL " + url);
      return;
    }

    if (!shouldUpdate(feedUrl)) {
      LOG.info(String.format("The feed url %1$s was updated at %2$s. Skipping update", url, feedUrl.getLastUpdated()));
      return;
    }

    LOG.info("Checking new entries for feed " + url);
    try {
      Document document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(url);
      SyndFeed syndFeed = new SyndFeedInput().build(document);
      for (SyndEntry receivedEntry : (List<SyndEntry>) syndFeed.getEntries()) {
        if (!feedRepository.isExistentEntry(receivedEntry.getLink(), url)) {
          addFeedEntry(url, receivedEntry);
        }
      }
      feedUrl.setLastUpdated(new Date());
      genericOperations.persist(feedUrl);
    } catch (Exception e) {
      LOG.error("Error while reading feed " + feedUrl, e);
    }
  }

  private boolean shouldUpdate(FeedUrl feedUrl) {
    if (feedUrl.getLastUpdated() == null) {
      return true;
    }
    return System.currentTimeMillis() - feedUrl.getLastUpdated().getTime() > FEED_UPDATE_THRESHOLD * 60 * 1000;
  }

  private void addFeedEntry(String feedUrl, SyndEntry receivedEntry) {
    FeedEntry newFeedEntry = new FeedEntry();
    newFeedEntry.setFeedUrl(feedUrl);
    newFeedEntry.setLink(receivedEntry.getLink());
    newFeedEntry.setTitle(receivedEntry.getTitle());
    newFeedEntry.setAuthor(receivedEntry.getAuthor());
    if (receivedEntry.getDescription() != null) {
      newFeedEntry.setDescription(new Text(receivedEntry.getDescription().getValue()));
    } else if (receivedEntry.getContents() != null && receivedEntry.getContents().size() > 0) {
      newFeedEntry.setDescription(new Text(((SyndContent) receivedEntry.getContents().get(0)).getValue()));
    }

    newFeedEntry.setPublishedDate(receivedEntry.getPublishedDate());
    newFeedEntry.setReceiveDate(new Date());
    LOG.info("Adding new entry " + newFeedEntry);
    genericOperations.persist(newFeedEntry);
  }

  public void sendNewEntries(Long subscriptionId) {
    Subscription subscription = findSubscriptionByKey(subscriptionId);
    if (subscription == null) {
      LOG.warn("Could not find subscription with id " + subscriptionId);
      return;
    }

    sendNewEntries(subscription);
    scheduleNextSend(subscription);
    genericOperations.persist(subscription);
  }

  private void scheduleNextSend(Subscription subscription) {
    if (!subscription.isEnabled()) {
      LOG.info("Skipping scheduling subscription " + subscription.getPrefix() + " as it is not enabled");
      return;
    }

    User user = genericOperations.find(new Key<User>(User.class, subscription.getUserId()));

    Date nextScheduledTime = getNextScheduledSendTime(subscription, user.getTimeZone());
    if (nextScheduledTime == null) {
      LOG.error("Error determining the next scheduled time for subscription " + subscription.getPrefix() + ". This subscription won't be checked.");
      return;
    }

    String url = String.format(SEND_SUBSCRIPTION_PATTERN, subscription.getId());
    if (scheduleTasks) {
      Queue defaultQueue = QueueFactory.getDefaultQueue();

      Long taskHandleCacheKey = subscription.getId();
      TaskHandle existingTaskHandle = (TaskHandle) memcacheService.get(taskHandleCacheKey);
      if (existingTaskHandle != null) {
        defaultQueue.deleteTask(existingTaskHandle);
      }
      TaskHandle taskHandle = defaultQueue.add(withUrl(url).method(TaskOptions.Method.GET).etaMillis(nextScheduledTime.getTime()));
      memcacheService.put(taskHandleCacheKey, taskHandle);
    }
    
    LOG.info("Scheduled next send of url " + url + " at " + nextScheduledTime);
    subscription.setNextCheck(nextScheduledTime);
  }

  private Date getNextScheduledSendTime(Subscription subscription, String timeZone) {
    Date nextScheduledTime = null;

    /*  the cron expression might contain multiple expressions separated by ";" */
    String[] scheduledCronExpressions = subscription.getSendCronExpression().split(";");
    
    for (String cronExpr : scheduledCronExpressions) {
      try {
        CronExpression cronExpression = (CronExpression) memcacheService.get(cronExpr);
        if (cronExpression == null) {
          cronExpression = new CronExpression(cronExpr);
          memcacheService.put(cronExpr, cronExpression, Expiration.byDeltaSeconds(31 * 24 * 3600)); // cache it for 31 days.
        }

        Date nextTriggerTimeForExpression;
        synchronized (cronExpr) {
          cronExpression.setTimeZone(TimeZone.getTimeZone(timeZone));
          nextTriggerTimeForExpression = cronExpression.getNextValidTimeAfter(new Date());
        }

        if (nextScheduledTime == null || nextTriggerTimeForExpression.before(nextScheduledTime)) {
          nextScheduledTime = nextTriggerTimeForExpression;
        }
      } catch (ParseException e) {
        LOG.error("Error while parsing cron expression " + cronExpr + " for subscription " + subscription.getPrefix());
      }
    }
    return nextScheduledTime;
  }

  private void sendNewEntries(Subscription subscription) {
    LOG.info("Sending new entries for subscription " + subscription.getPrefix());

    updateFeeds(subscription);
    computeNewEntries(subscription);
    if (!subscription.getNonEmptyFeeds().isEmpty()) {
      User user = genericOperations.find(new Key<User>(User.class, subscription.getUserId()));
      String html = htmlGenerator.generateHtmlForSubscription(subscription, user);

      DateFormat format = new SimpleDateFormat(FILE_SUFFIX_PATTERN);
      format.setTimeZone(TimeZone.getTimeZone(user.getTimeZone()));
      subscription.getUserId();
      String fileName = String.format("%1$s %2$s.html", subscription.getPrefix(), format.format(new Date()));

      mailSender.sendMessage(html, fileName, user.getKindleAccount());
    } else {
      LOG.info("No new entries to send found for subscription " + subscription.getPrefix());
    }

    subscription.setLastChecked(new Date());
  }

  private void computeNewEntries(Subscription subscription) {
    List<Feed> nonEmptyFeeds = new ArrayList<Feed>();
    for (Feed feed : subscription.getFeeds()) {
      List<FeedEntry> feedNewEntries = feedRepository.getNewEntries(subscription, feed.getUrl());
      if (feedNewEntries != null && !feedNewEntries.isEmpty()) {
        feed.setNewEntries(feedNewEntries);
        nonEmptyFeeds.add(feed);
      }
    }
    subscription.setNonEmptyFeeds(nonEmptyFeeds);
  }

  private void updateFeeds(Subscription subscription) {
    for (Feed feed : subscription.getFeeds()) {
      updateFeed(feed.getUrl());
    }
  }

  private Subscription findSubscriptionByKey(Long subscriptionId) {
    com.googlecode.objectify.Key<Subscription> key = new com.googlecode.objectify.Key<Subscription>(Subscription.class, subscriptionId);
    return genericOperations.find(key);
  }

  public StructuredHtmlGenerator getHtmlGenerator() {
    return htmlGenerator;
  }

  public void setHtmlGenerator(StructuredHtmlGenerator htmlGenerator) {
    this.htmlGenerator = htmlGenerator;
  }

  public KindleMailSender getMailSender() {
    return mailSender;
  }

  public void setMailSender(KindleMailSender mailSender) {
    this.mailSender = mailSender;
  }

  public GenericOperations getGenericOperations() {
    return genericOperations;
  }

  public void setGenericOperations(GenericOperations genericOperations) {
    this.genericOperations = genericOperations;
  }

  public FeedRepository getFeedRepository() {
    return feedRepository;
  }

  public void setFeedRepository(FeedRepository feedRepository) {
    this.feedRepository = feedRepository;
  }

  public boolean isScheduleTasks() {
    return scheduleTasks;
  }

  public void setScheduleTasks(boolean scheduleTasks) {
    this.scheduleTasks = scheduleTasks;
  }

  public MemcacheService getMemcacheService() {
    return memcacheService;
  }

  public void setMemcacheService(MemcacheService memcacheService) {
    this.memcacheService = memcacheService;
  }
}
